import { Subject } from "../Subject"
import { QueryRunner } from "../../query-runner/QueryRunner"
import { OrmUtils } from "../../util/OrmUtils"
import { NestedSetMultipleRootError } from "../../error/NestedSetMultipleRootError"
import { ObjectLiteral } from "../../common/ObjectLiteral"
import { EntityMetadata } from "../../metadata/EntityMetadata"

class NestedSetIds {
    left: number
    right: number
}

/**
 * Executes subject operations for nested set tree entities.
 */
export class NestedSetSubjectExecutor {
    // -------------------------------------------------------------------------
    // Constructor
    // -------------------------------------------------------------------------

    constructor(protected queryRunner: QueryRunner) {}

    // -------------------------------------------------------------------------
    // Public Methods
    // -------------------------------------------------------------------------

    /**
     * Executes operations when subject is being inserted.
     */
    async insert(subject: Subject): Promise<void> {
        const escape = (alias: string) =>
            this.queryRunner.connection.driver.escape(alias)
        const tableName = this.getTableName(subject.metadata.tablePath)
        const leftColumnName = escape(
            subject.metadata.nestedSetLeftColumn!.databaseName,
        )
        const rightColumnName = escape(
            subject.metadata.nestedSetRightColumn!.databaseName,
        )

        let parent = subject.metadata.treeParentRelation!.getEntityValue(
            subject.entity!,
        ) // if entity was attached via parent
        if (!parent && subject.parentSubject && subject.parentSubject.entity)
            // if entity was attached via children
            parent = subject.parentSubject.insertedValueSet
                ? subject.parentSubject.insertedValueSet
                : subject.parentSubject.entity
        const parentId = subject.metadata.getEntityIdMap(parent)

        let parentNsRight: number | undefined = undefined
        if (parentId) {
            parentNsRight = await this.queryRunner.manager
                .createQueryBuilder()
                .select(
                    subject.metadata.targetName +
                        "." +
                        subject.metadata.nestedSetRightColumn!.propertyPath,
                    "right",
                )
                .from(subject.metadata.target, subject.metadata.targetName)
                .whereInIds(parentId)
                .getRawOne()
                .then((result) => {
                    const value: any = result ? result["right"] : undefined
                    // CockroachDB returns numeric types as string
                    return typeof value === "string" ? parseInt(value) : value
                })
        }

        if (parentNsRight !== undefined) {
            await this.queryRunner.query(
                `UPDATE ${tableName} SET ` +
                    `${leftColumnName} = CASE WHEN ${leftColumnName} > ${parentNsRight} THEN ${leftColumnName} + 2 ELSE ${leftColumnName} END,` +
                    `${rightColumnName} = ${rightColumnName} + 2 ` +
                    `WHERE ${rightColumnName} >= ${parentNsRight}`,
            )

            OrmUtils.mergeDeep(
                subject.insertedValueSet,
                subject.metadata.nestedSetLeftColumn!.createValueMap(
                    parentNsRight,
                ),
                subject.metadata.nestedSetRightColumn!.createValueMap(
                    parentNsRight + 1,
                ),
            )
        } else {
            const isUniqueRoot = await this.isUniqueRootEntity(subject, parent)

            // Validate if a root entity already exits and throw an exception
            if (!isUniqueRoot) throw new NestedSetMultipleRootError()

            OrmUtils.mergeDeep(
                subject.insertedValueSet,
                subject.metadata.nestedSetLeftColumn!.createValueMap(1),
                subject.metadata.nestedSetRightColumn!.createValueMap(2),
            )
        }
    }

    /**
     * Executes operations when subject is being updated.
     */
    async update(subject: Subject): Promise<void> {
        let parent = subject.metadata.treeParentRelation!.getEntityValue(
            subject.entity!,
        ) // if entity was attached via parent
        if (!parent && subject.parentSubject && subject.parentSubject.entity)
            // if entity was attached via children
            parent = subject.parentSubject.entity

        let entity = subject.databaseEntity // if entity was attached via parent
        if (!entity && parent)
            // if entity was attached via children
            entity = subject.metadata
                .treeChildrenRelation!.getEntityValue(parent)
                .find((child: any) => {
                    return Object.entries(subject.identifier!).every(
                        ([key, value]) => child[key] === value,
                    )
                })

        // Exit if the parent or the entity where never set
        if (entity === undefined || parent === undefined) {
            return
        }

        const oldParent = subject.metadata.treeParentRelation!.getEntityValue(
            entity!,
        )
        const oldParentId = subject.metadata.getEntityIdMap(oldParent)
        const parentId = subject.metadata.getEntityIdMap(parent)

        // Exit if the new and old parents are the same
        if (OrmUtils.compareIds(oldParentId, parentId)) {
            return
        }

        if (parent) {
            const escape = (alias: string) =>
                this.queryRunner.connection.driver.escape(alias)
            const tableName = this.getTableName(subject.metadata.tablePath)
            const leftColumnName = escape(
                subject.metadata.nestedSetLeftColumn!.databaseName,
            )
            const rightColumnName = escape(
                subject.metadata.nestedSetRightColumn!.databaseName,
            )

            const entityId = subject.metadata.getEntityIdMap(entity)

            let entityNs: NestedSetIds | undefined = undefined
            if (entityId) {
                entityNs = (
                    await this.getNestedSetIds(subject.metadata, entityId)
                )[0]
            }

            let parentNs: NestedSetIds | undefined = undefined
            if (parentId) {
                parentNs = (
                    await this.getNestedSetIds(subject.metadata, parentId)
                )[0]
            }

            if (entityNs !== undefined && parentNs !== undefined) {
                const isMovingUp = parentNs.left > entityNs.left
                const treeSize = entityNs.right - entityNs.left + 1

                let entitySize: number
                if (isMovingUp) {
                    entitySize = parentNs.left - entityNs.right
                } else {
                    entitySize = parentNs.right - entityNs.left
                }

                // Moved entity logic
                const updateLeftSide =
                    `WHEN ${leftColumnName} >= ${entityNs.left} AND ` +
                    `${leftColumnName} < ${entityNs.right} ` +
                    `THEN ${leftColumnName} + ${entitySize} `

                const updateRightSide =
                    `WHEN ${rightColumnName} > ${entityNs.left} AND ` +
                    `${rightColumnName} <= ${entityNs.right} ` +
                    `THEN ${rightColumnName} + ${entitySize} `

                // Update the surrounding entities
                if (isMovingUp) {
                    await this.queryRunner.query(
                        `UPDATE ${tableName} ` +
                            `SET ${leftColumnName} = CASE ` +
                            `WHEN ${leftColumnName} > ${entityNs.right} AND ` +
                            `${leftColumnName} <= ${parentNs.left} ` +
                            `THEN ${leftColumnName} - ${treeSize} ` +
                            updateLeftSide +
                            `ELSE ${leftColumnName} ` +
                            `END, ` +
                            `${rightColumnName} = CASE ` +
                            `WHEN ${rightColumnName} > ${entityNs.right} AND ` +
                            `${rightColumnName} < ${parentNs.left} ` +
                            `THEN ${rightColumnName} - ${treeSize} ` +
                            updateRightSide +
                            `ELSE ${rightColumnName} ` +
                            `END`,
                    )
                } else {
                    await this.queryRunner.query(
                        `UPDATE ${tableName} ` +
                            `SET ${leftColumnName} = CASE ` +
                            `WHEN ${leftColumnName} < ${entityNs.left} AND ` +
                            `${leftColumnName} > ${parentNs.right} ` +
                            `THEN ${leftColumnName} + ${treeSize} ` +
                            updateLeftSide +
                            `ELSE ${leftColumnName} ` +
                            `END, ` +
                            `${rightColumnName} = CASE ` +
                            `WHEN ${rightColumnName} < ${entityNs.left} AND ` +
                            `${rightColumnName} >= ${parentNs.right} ` +
                            `THEN ${rightColumnName} + ${treeSize} ` +
                            updateRightSide +
                            `ELSE ${rightColumnName} ` +
                            `END`,
                    )
                }
            }
        } else {
            const isUniqueRoot = await this.isUniqueRootEntity(subject, parent)

            // Validate if a root entity already exits and throw an exception
            if (!isUniqueRoot) throw new NestedSetMultipleRootError()
        }
    }

    /**
     * Executes operations when subject is being removed.
     */
    async remove(subjects: Subject | Subject[]): Promise<void> {
        if (!Array.isArray(subjects)) subjects = [subjects]

        const metadata = subjects[0].metadata

        const escape = (alias: string) =>
            this.queryRunner.connection.driver.escape(alias)
        const tableName = this.getTableName(metadata.tablePath)
        const leftColumnName = escape(
            metadata.nestedSetLeftColumn!.databaseName,
        )
        const rightColumnName = escape(
            metadata.nestedSetRightColumn!.databaseName,
        )

        const entitiesIds: ObjectLiteral[] = []
        for (const subject of subjects) {
            const entityId = metadata.getEntityIdMap(subject.entity)

            if (entityId) {
                entitiesIds.push(entityId)
            }
        }

        const entitiesNs = await this.getNestedSetIds(metadata, entitiesIds)

        for (const entity of entitiesNs) {
            const treeSize = entity.right - entity.left + 1

            await this.queryRunner.query(
                `UPDATE ${tableName} ` +
                    `SET ${leftColumnName} = CASE ` +
                    `WHEN ${leftColumnName} > ${entity.left} THEN ${leftColumnName} - ${treeSize} ` +
                    `ELSE ${leftColumnName} ` +
                    `END, ` +
                    `${rightColumnName} = CASE ` +
                    `WHEN ${rightColumnName} > ${entity.right} THEN ${rightColumnName} - ${treeSize} ` +
                    `ELSE ${rightColumnName} ` +
                    `END`,
            )
        }
    }

    /**
     * Get the nested set ids for a given entity
     */
    protected getNestedSetIds(
        metadata: EntityMetadata,
        ids: ObjectLiteral | ObjectLiteral[],
    ): Promise<NestedSetIds[]> {
        const select = {
            left: `${metadata.targetName}.${
                metadata.nestedSetLeftColumn!.propertyPath
            }`,
            right: `${metadata.targetName}.${
                metadata.nestedSetRightColumn!.propertyPath
            }`,
        }

        const queryBuilder = this.queryRunner.manager.createQueryBuilder()

        Object.entries(select).forEach(([key, value]) => {
            queryBuilder.addSelect(value, key)
        })

        return queryBuilder
            .from(metadata.target, metadata.targetName)
            .whereInIds(ids)
            .orderBy(select.right, "DESC")
            .getRawMany()
            .then((results) => {
                const data: NestedSetIds[] = []

                for (const result of results) {
                    const entry: any = {}
                    for (const key of Object.keys(select)) {
                        const value = result ? result[key] : undefined

                        // CockroachDB returns numeric types as string
                        entry[key] =
                            typeof value === "string" ? parseInt(value) : value
                    }
                    data.push(entry)
                }

                return data
            })
    }

    private async isUniqueRootEntity(
        subject: Subject,
        parent: any,
    ): Promise<boolean> {
        const escape = (alias: string) =>
            this.queryRunner.connection.driver.escape(alias)
        const tableName = this.getTableName(subject.metadata.tablePath)
        const parameters: any[] = []
        const whereCondition = subject.metadata
            .treeParentRelation!.joinColumns.map((column) => {
                const columnName = escape(column.databaseName)
                const parameter = column.getEntityValue(parent)

                if (parameter == null) {
                    return `${columnName} IS NULL`
                }

                parameters.push(parameter)
                const parameterName =
                    this.queryRunner.connection.driver.createParameter(
                        "entity_" + column.databaseName,
                        parameters.length - 1,
                    )
                return `${columnName} = ${parameterName}`
            })
            .join(" AND ")

        const countAlias = "count"
        const result = await this.queryRunner.query(
            `SELECT COUNT(1) AS ${escape(
                countAlias,
            )} FROM ${tableName} WHERE ${whereCondition}`,
            parameters,
            true,
        )

        return parseInt(result.records[0][countAlias]) === 0
    }

    /**
     * Gets escaped table name with schema name if SqlServer or Postgres driver used with custom
     * schema name, otherwise returns escaped table name.
     */
    protected getTableName(tablePath: string): string {
        return tablePath
            .split(".")
            .map((i) => {
                // this condition need because in SQL Server driver when custom database name was specified and schema name was not, we got `dbName..tableName` string, and doesn't need to escape middle empty string
                return i === ""
                    ? i
                    : this.queryRunner.connection.driver.escape(i)
            })
            .join(".")
    }
}
